fix: propagate proxy settings to SSH environment#1012
Conversation
0d111a7 to
c830811
Compare
Set HTTP_PROXY/HTTPS_PROXY/NO_PROXY on the extension host's process.env from VS Code's http.proxy / coder.proxyBypass / http.noProxy so the spawned `coder ssh` ProxyCommand inherits them. Mutating process.env avoids writing a credentialed proxy URL to the SSH config and keeps multiple windows onto the same workspace independent. Watch the proxy settings so the user is prompted to reload when SSH proxy behavior changes.
When a proxy applies to the deployment but remote.SSH.useLocalServer is off, the spawned SSH connection won't inherit the proxy env. Surface a dismissable warning offering to enable the local server, via a reusable showDismissibleNotification helper.
c830811 to
d4ed808
Compare
Move showDismissibleNotification from util into a DismissibleNotifier class under core: it holds globalState and constrains keys to a known DismissibleNotificationKey set, wired through ServiceContainer. Rename applySshProxyEnvironment to applySshEnvironment, generalize its doc to "SSH-related environment variables", keep the internal env type unexported, and order the file public-API first. Make the useLocalServer warning a blocking warning modal so the setting is written (via the jsonc settings util, not cfg.update) before ssh spawns, which lets it apply without a window reload.
db5ca20 to
da8f590
Compare
| export const DISMISSIBLE_NOTIFICATION_KEYS = [ | ||
| "coder.proxyUseLocalServerWarningDismissed", | ||
| ] as const; | ||
|
|
||
| export type DismissibleNotificationKey = | ||
| (typeof DISMISSIBLE_NOTIFICATION_KEYS)[number]; |
There was a problem hiding this comment.
Why not enum? This looks basically like an enum.
Or, if the idea is that we want to pass string literals then we could just do type DISMISSIBLE_NOTIFICATION_KEYS = "key" | "key2" since it does not look like we use the const for anything right now.
But if we will need to iterate over it or something like that in the future, then ignore me.
| * Best-effort: only processes spawned afterwards inherit the change. MS VS Code | ||
| * with `remote.SSH.useLocalServer=false` spawns ssh off a path that does not | ||
| * inherit, so propagation there needs `useLocalServer=true`. Returns a disposable | ||
| * that restores the previous values. |
There was a problem hiding this comment.
How would the location of the SSH binary change whether environment variables are inherited? Or by path do you mean the code path and not the binary path?
If I understand correctly, true spawns a single SSH process, and then VS Code multiplexes all windows connected to that remote using that single SSH connection.
And then false spawns SSH in a hidden terminal per window. And I guess when they do it this way, they are probably explicitly setting env which prevents inheriting as a side effect?
| const proxy = httpProxy | ||
| ? getProxyForUrl(baseUrl, httpProxy, noProxy, undefined) |
There was a problem hiding this comment.
Why do we call this differently here than in the plugin? We could just pass the fallback as the fourth option to get the same behavior, right?
I think maybe it is so we can use noProxy below but this seems a bit wrong because getProxyForUrl also checks other variables that were not included here (like npm_config_no_proxy). Maybe getProxyForUrl could return both proxy and noProxy?
Actually, there is no need to even set noProxy right? Because getProxyForUrl will return a blank string if any noProxy is in effect.
Or, maybe better to skip getProxyForUrl and just pass everything in and let the CLI figure it out. For example maybe the CLI tries to use a different region rather than the main deployment domain or something.
|
|
||
| function getEnvKeys(env: Environment, key: string): string[] { | ||
| const keys = Object.keys(env).filter( | ||
| (envKey) => envKey.toLowerCase() === key.toLowerCase(), |
There was a problem hiding this comment.
Environment variables are case-sensitive (at least, in Linux, Windows might be different) so I think we should not compare with lowercase or we technically cause collisions.
| return; | ||
| } | ||
| disposed = true; | ||
| for (let i = previous.length - 1; i >= 0; i--) { |
There was a problem hiding this comment.
Is this not using a for of like above because it needs to iterate backwards? The keys are unique though so I am not sure why the order matters.
| : ""; | ||
|
|
||
| return { | ||
| ...(proxy ? { HTTP_PROXY: proxy, HTTPS_PROXY: proxy } : {}), |
There was a problem hiding this comment.
Is it right to set both http_proxy and https_proxy? If the user set http_proxy I imagine we should not set it on https_proxy, and maybe vice versa. idk why someone would have Coder on an http URL though, other than for maybe testing.
| baseUrl: string, | ||
| cfg: Pick<WorkspaceConfiguration, "get">, | ||
| ): SshEnvironment { | ||
| const httpProxy = getSetting(cfg, "http.proxy"); |
There was a problem hiding this comment.
nbd but could just do cfg.get right? A blank or even untrimmed string with spaces would not break anything here (and we are not doing it elsewhere when we get the proxy URL, so if it does break we should do it there as well).
| const ENABLE = "Enable Local Server"; | ||
| const choice = await this.dismissibleNotifier.showDismissible( | ||
| "coder.proxyUseLocalServerWarningDismissed", | ||
| "Your proxy settings may not reach the SSH connection because `remote.SSH.useLocalServer` is disabled. Enable it so Coder can apply the proxy to the connection.", |
There was a problem hiding this comment.
Have we validated this? I wonder if VS Code might actually explicitly include http_proxy when spawning, it does seem reasonable. But maybe not.
| // Use the jsonc writer, not cfg.update which can hang during remote setup. | ||
| // No reload needed: ssh hasn't spawned yet, so it picks this up on connect. | ||
| const ok = await applySettingOverrides( | ||
| this.pathResolver.getUserSettingsPath(), | ||
| [{ key: "remote.SSH.useLocalServer", value: true }], | ||
| this.logger, | ||
| ); | ||
| if (!ok) { | ||
| this.logger.warn("Failed to enable remote.SSH.useLocalServer"); | ||
| } |
There was a problem hiding this comment.
Can we change the setting through the API or does it only let us change our own settings? I think I remember trying it through the API before but it was not possible.
Summary
process.envbefore Remote-SSH starts the local SSH/ProxyCommand flowHTTP_PROXYandHTTPS_PROXYfromhttp.proxywhen it applies to the Coder deployment URLNO_PROXYfromcoder.proxyBypass, falling back tohttp.noProxyremote.SSH.useLocalServeris disabled, since propagation can't reach the connection in that caseTesting
pnpm lintpnpm typecheckpnpm test:extension ./test/unit/api/proxy.test.ts ./test/unit/api/utils.test.ts ./test/unit/remote/environment.test.ts ./test/unit/util/notifications.test.tsManual testing matrix
Verified that the proxy variables actually reach the spawned
coder sshProxyCommand process by injecting a marker variable into the extension-hostprocess.envand reading the live ProxyCommand process's environment (/proc/<pid>/environ) once connected.useLocalServer=trueuseLocalServer=falseNotes:
useLocalServer=false, which spawnssshoff a path that does not inherit the extension host'sprocess.env.useExecServermade no difference either way.Delivery options considered
The
coderCLI has no proxy flag and readsHTTP_PROXY/HTTPS_PROXY/NO_PROXYfrom its own process environment (standard Go HTTP client), so the goal is getting those variables into thecoder sshProxyCommand process. Three approaches:process.env(chosen) — spawned ssh children inherit it.HTTP_PROXY=... coder ssh ...).We went with A because it is the least intrusive (no file writes) and handles multiple windows onto the same connection cleanly: B and C bake a single static value into a file shared across windows, whereas A keeps values in memory and per-window, leaving room for per-window variables later (e.g. a
SESSION_ID). The tradeoff is that A is best-effort for theuseLocalServer=falsecase noted above, which is why we additionally surface the dismissable warning for that combination.Closes #1010